/*
 * Created on 14.11.2012
 */
package net.tumblr.backup;

import static java.util.logging.Level.SEVERE;
import static java.util.logging.Level.WARNING;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.util.TreeMap;
import java.util.logging.Logger;

import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.HttpResponseInterceptor;
import org.apache.http.ProtocolException;
import org.apache.http.client.entity.GzipDecompressingEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.protocol.HttpContext;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * Incremental backup of photos of a Tumblr blog, starting with the oldest post.
 *
 * @author cgaffga
 * @version $Id: Backup.java 10 2012-12-11 12:51:10Z cgaffga $
 */
public final class Backup {

    /** Logger for this class. */
    private static final Logger log = Logger.getLogger(Backup.class.getName());

    /**
     * Tumblr API-key.
     *
     * @see "http://www.tumblr.com/docs/en/api/v2#auth"
     */
    private static String apiKey;

    /** URL of the Tumblr API. */
    private static final String API_URL = "http://api.tumblr.com/v2/blog/";

    /** Name of the blog to backup. */
    private static String blogName;

    /** Target directory for the backup. */
    private static String directory;

    /** Time to sleep after each picture download (unit is milliseconds). */
    private static long sleep = 3 * 1000L; // ms

    /**
     * Fetch only the blog info as JSON.
     *
     * @return Returns the blog-info part of blog-info JSON request.-
     * @throws IOException Thrown if a HTTP or JSON error occurred while fetching the blog info.
     * @see "http://www.tumblr.com/docs/en/api/v2#blog-info"
     */
    private static JSONObject fetchBlogInfo() throws IOException {
        final String body = fetchHttp(API_URL + blogName + "/info?api_key=" + apiKey);
        try {
            final JSONObject json = new JSONObject(body);
            return json.getJSONObject("response").getJSONObject("blog");
        } catch (final JSONException e) {
            throw new IOException("JSON response error!", e);
        }
    }

    /**
     * Fetch a batch of 20 posts via the Tumblr API. The notes and reblog-info flags are turned on.
     *
     * @param offset The offset of the batch to fetch, 0 is the latest post, greater zero is in the past.
     * @return An object containing the <i>posts</i> array JSON data structures and the <i>blog</i> info..
     * @throws IOException Thrown if a HTTP or JSON error occurred while fetching the result list of posts.
     * @see "http://www.tumblr.com/docs/en/api/v2#posts"
     */
    private static JSONObject fetchBlogPosts(final int offset) throws IOException {
        final String body = fetchHttp(API_URL + blogName + "/posts?api_key=" + apiKey + "&limit=20&offset=" + offset
                + "&reblog_info=true&notes_info=true");
        try {
            final JSONObject json = new JSONObject(body);
            return json.getJSONObject("response");
        } catch (final JSONException e) {
            throw new IOException("JSON response error!", e);
        }
    }

    /**
     * Returns the command line argument for an option switch.
     *
     * @param option The option to look for in the command line argument, e.g. {@code -h} for help.
     * @param args Command line arguments to scan.
     * @return Returns the value of the option switch, or {@code null} if it was not set.
     */
    private static String getArgument(final String option, final String[] args) {
        for (int i = 0; i < args.length; i++) {
            if (option.equals(args[i])) {
                if (i + 1 < args.length)
                    return args[i + 1];
                else
                    return "";
            }
        }
        return null;
    }

    /** @see Backup */
    // NOJAVADOC
    public static void main(final String[] args) {
        // print help and exit...
        if (args.length == 0 || getArgument("-h", args) != null || getArgument("-?", args) != null
                || getArgument("--help", args) != null) {
            // @formatter:off
            System.out.println( // NOPMD it's OK to use println() here to print the help! no logging!
                "Usage: java -jar tumblr-backup.jar -Xmx256m -d <dir> -b <blog> -k <key> [-s <sleep>]\n"
              + "  -d target directory for the backed up files, will be created if needed\n"
              + "  -b blog name, e.g. myblog.tumblr.com\n"
              + "  -k your API key to access tumblr, you can get one\n"
              + "     on http://www.tumblr.com/oauth/register\n"
              + "  -s the sleep-time for throtteling downloads, wait this many millis after\n"
              + "     each download. Defaults to 3000 (3 seconds)\n"
              + "  -h -? --help show this help"
            );
            // @formatter:on
            return;
        }

        // read command line options...
        directory = getArgument("-d", args);
        if (directory == null) {
            log.severe("Target directory must be set, use the -d swith.");
            return;
        }
        blogName = getArgument("-b", args);
        if (blogName == null) {
            log.severe("Blog name must be set, use the -b swith.");
            return;
        }
        apiKey = getArgument("-k", args);
        if (apiKey == null) {
            log.severe("API key must be set, use the -k swith and get one on http://www.tumblr.com/oauth/register.");
            return;
        }
        if (getArgument("-s", args) != null) {
            try {
                sleep = Long.parseLong(getArgument("-s", args));
            } catch (final NumberFormatException e) {
                log.severe("Incorrect value for -s switch. Value must be a number in millisecods, but it's '"
                        + getArgument("-s", args) + "'");
                return;
            }
        }

        // create output directory...
        log.info("Create output directory " + directory);
        new File(directory + "/pics").mkdirs();

        // copy index file...
        try (final InputStream in = Backup.class.getResourceAsStream("index.html");
             final OutputStream out = new FileOutputStream(directory + "/index.html")) {
            int c;
            while ((c = in.read()) >= 0) {
                out.write(c);
            }
        } catch (final IOException e) {
            log.log(SEVERE, "Could not copy index.html file!", e);
            return;
        }

        // create backup object...
        final File backupFile = new File(directory + "/backup.json");
        final File jsFile = new File(directory + "/backup.js");
        final Backup backup;
        try {
            backup = new Backup(backupFile);
        } catch (final IOException e) {
            log.log(SEVERE, "Could not read backup file or create new backup!", e);
            return;
        }
        // update blog info...
        try {
            backup.blog = fetchBlogInfo();
        } catch (final IOException e) {
            log.log(SEVERE, "Error reading blog info!", e);
            return;
        }

        // backup batches...
        try {
            int offset = backup.getBlogPostCount() - backup.posts.size() - 10;
            offset = offset < 0 ? 0 : offset;
            int batchNo = 0;
            while (offset >= 0) {
                fetchBatch(backup, offset);
                offset = backup.getBlogPostCount() - backup.posts.size() - 18;
                // save backup data every x batches...
                if (++batchNo % 5 == 0) {
                    backup.save(backupFile, jsFile);
                }
            } // NEXT batch.
        } catch (final IOException e) {
            log.log(SEVERE, "Error reading batch!", e);
            return;
        }

        // save backup data file at the end...
        try {
            backup.save(backupFile, jsFile);
        } catch (final IOException e) {
            log.log(SEVERE, "Can't write JSON backup data to file!", e);
        }
    }

    /** All posts already processed/backed-up. The key is the post's id. */
    private final TreeMap<Long, Post> posts;

    /** JSON info about the backed up blog. */
    private JSONObject blog;

    /**
     * Shared {@link HttpClient} instance, supports GZip compression and set's some headers to look more like a normal
     * browser.
     */
    private static final DefaultHttpClient HTTP_CLIENT = new DefaultHttpClient();

    static {
        HTTP_CLIENT.addRequestInterceptor(new HttpRequestInterceptor() {
            @Override
            public void process(final HttpRequest request, final HttpContext context) throws HttpException, IOException {
                request.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
                request.addHeader("User-Agent",
                        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0");
                if (!request.containsHeader("Accept-Encoding")) {
                    request.addHeader("Accept-Encoding", "gzip");
                }
            }
        });

        HTTP_CLIENT.addResponseInterceptor(new HttpResponseInterceptor() {
            @Override
            public void process(final HttpResponse response, final HttpContext context) throws HttpException,
                    IOException {
                final HttpEntity entity = response.getEntity();
                if (entity != null) {
                    final Header ceheader = entity.getContentEncoding();
                    if (ceheader != null) {
                        final HeaderElement[] codecs = ceheader.getElements();
                        for (final HeaderElement codec : codecs) {
                            if (codec.getName().equalsIgnoreCase("gzip")) {
                                response.setEntity(new GzipDecompressingEntity(response.getEntity()));
                                return;
                            }
                        }
                    }
                }
            }
        });

        HTTP_CLIENT.setRedirectStrategy(new DefaultRedirectStrategy() {
            @Override
            public boolean isRedirected(final HttpRequest request, final HttpResponse response,
                    final HttpContext context) {
                boolean isRedirect = false;
                try {
                    isRedirect = super.isRedirected(request, response, context);
                } catch (final ProtocolException e) {
                    log.log(WARNING, "Problem during redirect!", e);
                }
                if (!isRedirect) {
                    final int responseCode = response.getStatusLine().getStatusCode();
                    if (responseCode == 301 || responseCode == 302)
                        return true;
                }
                return isRedirect;
            }
        });
    }

    /**
     * Fetches and backs-up the next batch of 20 posts, starting with post at {@code offset}. It also updates the
     * {@link #blog} property of the {@code backup}.
     *
     * @param backup The backup currently processed.
     * @param offset Starting offset, 0 is the latest post, greater zero are past posts.
     * @throws IOException Thrown if the batch could not be read. Exceptions while processing single posts are
     *             swallowed.
     */
    private static void fetchBatch(final Backup backup, final int offset) throws IOException {
        log.info("Fetch new batch of 20 posts starting at offset " + offset);
        final JSONObject json = fetchBlogPosts(offset);
        JSONArray posts;
        try {
            posts = json.getJSONArray("posts");
            backup.blog = json.getJSONObject("blog");
        } catch (final JSONException e) {
            throw new IOException("Could not find posts or blog info in JSON response", e);
        }

        for (int iPost = 0; iPost < posts.length(); iPost++) {
            try {
                // initialize post details...
                final Post post = new Post(posts.getJSONObject(iPost));
                try (OutputStream out = new FileOutputStream(directory + "/pics/" + post.getPostId() + ".json");
                     Writer writer = new OutputStreamWriter(out)) {
                    post.write(writer);
                }
                post.remove("notes");

                // did we backup this post before?..
                if (backup.getPost(post.getPostId()) != null) {
                    log.info("Skip this post, already backed up! Post #" + post.getPostId() + " of "
                            + post.getPostDate());
                    continue; // skip!
                }

                log.info("Process post #" + post.getPostId() + " of " + post.getPostDate());

                final JSONArray photos = post.getPhotos();
                for (int iPhoto = 0; iPhoto < photos.length(); iPhoto++) {
                    final JSONObject photo = photos.getJSONObject(iPhoto);
                    final String photoUrl = photo.getJSONObject("original_size").getString("url");
                    String fileExt = photoUrl.substring(photoUrl.lastIndexOf('.'));
                    if (!".jpeg".equalsIgnoreCase(fileExt) || !".jpg".equalsIgnoreCase(fileExt)
                            || !".png".equalsIgnoreCase(fileExt) || !".gif".equalsIgnoreCase(fileExt)
                            || !".bmp".equalsIgnoreCase(fileExt)) {
                        fileExt = ".jpeg";
                    }
                    final String filename = post.getPostId() + "_" + (iPhoto + 1) + fileExt;

                    log.info("Fetch photo " + photoUrl + " -> " + filename);
                    try {
                        fetchHttpFile(photoUrl, new File(directory + "/pics/" + filename));
                        post.addDownloadedPhoto(filename);
                    } catch (final IOException e) {
                        log.log(WARNING, "Could not fetch photo! postId=" + post.getPostId() + " photo=" + photoUrl, e);
                    }

                    // add the post to backup JSON...
                    backup.addPost(post);

                    // sleep a bit before processing the next photo...
                    try {
                        Thread.sleep(sleep);
                    } catch (final InterruptedException e) {
                        log.log(WARNING, "Interrupted while sleeping!", e);
                    }
                } // NEXT photo.
            } catch (final JSONException e) {
                log.log(WARNING, "JSON error!", e);
            }
        } // NEXT post.
    }

    /**
     * Fetch text-content from some URL.
     *
     * @param url Location to fetch content from.
     * @return Returns the text-content.
     * @throws IOException Thrown if an underlying error occurs or if the HTTP response returned a code≠200.
     * @see #HTTP_CLIENT
     */
    private static String fetchHttp(final String url) throws IOException {
        final HttpGet httpGet = new HttpGet(url);
        final HttpResponse response = HTTP_CLIENT.execute(httpGet);
        try {
            // HTTP error!
            if (response.getStatusLine().getStatusCode() != 200) {
                log.info("HTTP code ≠ 200! Could not fetch URL=" + url);
                httpGet.releaseConnection();
                throw new IOException("HTTP code ≠ 200! Could not fetch URL=" + url);
            }

            final HttpEntity entity = response.getEntity();
            final StringBuilder body = new StringBuilder();
            try (final InputStream in = entity.getContent()) {
                int c;
                while ((c = in.read()) >= 0) {
                    body.append((char) c);
                }
            }
            return body.toString();
        } finally {
            httpGet.releaseConnection();
        }
    }

    /**
     * Fethc a file via HTTP and store in on the filesystem.
     *
     * @param url URL of the file to fetch.
     * @param file Where to store the file.
     * @throws IOException Thrown if network of filesystem exception occured.
     */
    private static void fetchHttpFile(final String url, final File file) throws IOException {
        final HttpGet httpGet = new HttpGet(url);
        final HttpResponse response = HTTP_CLIENT.execute(httpGet);
        try {
            // HTTP error!
            if (response.getStatusLine().getStatusCode() != 200) {
                log.info("HTTP code ≠ 200! Could not fetch URL=" + url);
                httpGet.releaseConnection();
                throw new IOException("HTTP code ≠ 200! Could not fetch URL=" + url);
            }

            final HttpEntity entity = response.getEntity();
            try (final InputStream in = entity.getContent(); final FileOutputStream out = new FileOutputStream(file)) {
                final byte[] buf = new byte[1024];
                int n = -1;
                while ((n = in.read(buf)) != -1) {
                    out.write(buf, 0, n);
                }
            }
        } finally {
            httpGet.releaseConnection();
        }
    }

    /**
     * Constructor.
     *
     * @param postsFile File with JSON data of all posts.
     * @throws IOException Thrown if the file containing the posts could not be read.
     */
    public Backup(final File postsFile) throws IOException {
        this.posts = new TreeMap<>();
        if (postsFile.exists()) {
            log.info("Reading JSON data of last backup from " + postsFile.getPath());
            try (final InputStream in = new FileInputStream(postsFile)) {
                // read JSON backup file...
                final StringBuilder buf = new StringBuilder();
                int c;
                while ((c = in.read()) >= 0) {
                    buf.append((char) c);
                }
                // set blog info...
                final JSONObject json = new JSONObject(buf.toString());
                // read posts...
                this.blog = json.getJSONObject("blog");
                final JSONArray jsonPosts = json.getJSONArray("posts");
                for (int i = 0; i < jsonPosts.length(); i++) {
                    final Post post = new Post(jsonPosts.getJSONObject(i));
                    this.posts.put(post.getPostId(), post);
                } // NEXT post.
            } catch (final JSONException e) {
                throw new IOException("Error readind JSON data!", e);
            }
            log.info("Read " + this.posts.size() + " old posts.");
        }
    }

    /**
     * Adds another post to {@link #posts}.
     *
     * @param post Post to be added or that should replace an old post.
     */
    public void addPost(final Post post) {
        this.posts.put(post.getPostId(), post);
    }

    /**
     * Returns the total number of posts for the {@link #blog}.
     *
     * @return Number of posts.
     * @throws IllegalArgumentException Thrown if the data is invalid, caused by {@link JSONException}.
     */
    public int getBlogPostCount() throws IllegalArgumentException {
        try {
            return this.blog.getInt("posts");
        } catch (final JSONException e) {
            throw new IllegalArgumentException("Invalid blog info data!", e);
        }
    }

    /**
     * Returns a post by it's it from {@link #posts}.
     *
     * @param postId Id of the post to be returned.
     * @return Post or {@code null} if not present.
     */
    public Post getPost(final long postId) {
        return this.posts.get(postId);
    }

    /**
     * Save information about the status of the backup to file. Currently we save {@link #blog} and {@link #posts} to a
     * JSON file. It also creates a second file for use within HTML (JSONP).
     *
     * @param backupFile File to save the JSON data to.
     * @param jsFile File to save the JSONP-JS data to.
     * @throws IOException Thrown if the file could not be written or a {@link JSONException} was thrown.
     */
    public void save(final File backupFile, final File jsFile) throws IOException {

        // store for backup recovery...
        log.info("Store JSON backup data to " + backupFile.getPath());
        try {
            final JSONObject json = new JSONObject();
            // add blog info to JSON file...
            json.put("blog", this.blog);
            // add all the posts to JSON...
            final JSONArray jsonPosts = new JSONArray();
            for (final Post post : this.posts.values()) {
                jsonPosts.put(post.getMinimum());
            }
            json.put("posts", jsonPosts);
            // write to file...
            try (OutputStream out = new FileOutputStream(backupFile); Writer writer = new OutputStreamWriter(out)) {
                json.write(writer);
            }
        } catch (final JSONException e) {
            throw new IOException("Can't create JSON data to save!", e);
        }

        // store only the important parts for use in HTML (as JS-file)...
        log.info("Store JSONP-JS backup data to " + jsFile.getPath());
        try {
            final JSONObject json = new JSONObject();
            // add blog info to JSON file...
            json.put("blog", this.blog);
            // add all the posts to JSON...
            final JSONArray jsonPosts = new JSONArray();
            for (final Post post : this.posts.values()) {
                jsonPosts.put(post.getMinimum());
            }
            json.put("posts", jsonPosts);
            // write to file...
            try (OutputStream out = new FileOutputStream(jsFile); Writer writer = new OutputStreamWriter(out)) {
                writer.write("data=");
                json.write(writer);
                writer.write(";");
            }
        } catch (final JSONException e) {
            throw new IOException("Can't create JSONP-JS data to save!", e);
        }

    }

}
